跳到主要内容

Go 套接字 Socket 编程 - 建立 TCP 连接

TCP 是一个面向连接的服务

  • 它为 TCP 段中的数据提供了校验和。这样有助于确保抵达目的地的数据在传输过程中不会被网络损坏;
  • 它为每字节分配了一个序列号,这样,如果数据抵达目的地时真的错序了,接收端也能够按照恰当的顺序将其重装起来。(当然,TCP 并没有为每字节都附加一个序列号。实际上,每个 TCP 段的首部都包含了段中第一字节的序列号。这样,就隐含地知道了段中其他字节的序列号)
  • TCP 提供了一种确认-重传机制,以确保最终每个段都会被传送出去。

Go 的 Socket 编程

传统的 Socket 编程,以基于 TCP 协议的网络服务为例,客户端和服务端的实现流程通常是这样的:

  • 服务端和客户端初始化 socket,得到文件描述符;
  • 服务端调用 bind,将绑定在 IP 地址和端口;
  • 服务端调用 listen,进行监听;
  • 服务端调用 accept,等待客户端连接;
  • 客户端调用 connect,向服务器端的地址和端口发起连接请求;
  • 服务端 accept 返回用于传输的 socket 的文件描述符;
  • 客户端调用 write 写入数据;服务端调用 read 读取数据;
  • 客户端断开连接时,会调用 close,那么服务端 read 读取数据的时候,就会读取到了 EOF,待处理完数据后,服务端调用 close,表示连接关闭。

基于 UDP 协议的网络服务大致流程也是一样的,只是服务端和客户端之间不需要建立连接。

Dial 函数 ⭐

Go 语言标准库对上面的过程进行了抽象和封装,无论我们使用什么协议建立什么形式的连接,都只需要调用 net.Dial() 函数就可以了(这个函数包装常用的网络协议),从而大大简化了代码的编写量,下面我们就来看看该函数的用法。

// 函数原型
func Dial(network, address string) (Conn, error) {
var d Dialer
return d.Dial(network, address)
}

其中 network 参数表示传入的网络协议(比如 tcp、udp 等),address 参数表示传入的 IP 地址或域名,而端口号是可选的,如果需要指定的话,以「:」的形式跟在地址或域名的后面就好了。如果连接成功,该函数返回连接对象,否则返回 error。

来看一下几种常见协议的调用方式。

// tcp 连接
conn, err := net.Dial("tcp", "192.168.10.10:80")
// udp 连接
conn, err := net.Dial("udp", "192.168.10.10:8888")
// ICMP连接(使用协议名称):
conn, err := net.Dial("ip4:icmp", "www.baidu.com")
// ICMP连接(使用协议编号):
conn, err := net.Dial("ip4:1", "10.0.0.3")

目前,Dial() 函数支持如下几种网络协议:

  • tcp:代表 TCP 协议,其基于的 IP 协议的版本根据参数 address 的值自适应。
  • tcp4:代表基于 IP 协议第四版的 TCP 协议。
  • tcp6:代表基于 IP 协议第六版的 TCP 协议。
  • udp:代表 UDP 协议,其基于的 IP 协议的版本根据参数 address 的值自适应。
  • udp4:代表基于 IP 协议第四版的 UDP 协议。
  • udp6:代表基于 IP 协议第六版的 UDP 协议。
  • unix:代表 Unix 通信域下的一种内部 socket 协议,以 SOCK_STREAM 为 socket 类型。
  • unixgram:代表 Unix 通信域下的一种内部 socket 协议,以 SOCK_DGRAM 为 socket 类型。
  • unixpacket:代表 Unix 通信域下的一种内部 socket 协议,以 SOCK_SEQPACKET 为 socket 类型。

在成功建立连接后,我们就可以进行数据的发送和接收,发送数据时,使用连接对象 conn 的 Write() 方法,接收数据时使用 Read() 方法。

建立一个 TCP 客户端

注意:实际上,Dial() 函数是对 dialTCP()dialUDP()dialIP()dialUnix() 的封装,这可以通过追溯 Dial() 函数的源码看到,底层真正建立连接是通过 dialSingle() 函数完成的:

所以也可以直接调用对应的 Dial 方法,例如通过 DialTCP 可以建立一条 TCP 连接(Dial 拨号)

// 其中 laddr 是本地地址,通常设置为 nil 和 raddr 是一个服务的远程地址,net 是一个字符串,
// 根据是否希望是一个 TCPv4 连接,TCPv6 连接来设置为 "tcp4", "tcp6" 或 "tcp" 中的一个
func DialTCP(net string, laddr, raddr *TCPAddr) (c *TCPConn, err os.Error

一旦客户端已经建立 TCP 服务, 就可以和对方设备 "通话" 了。如果成功,该调用返回一个用于通信的 TCPConn。

tcpAddr, err := net.ResolveTCPAddr("tcp", "www.baidu.com:80")
conn, err := net.DialTCP("tcp", nil, tcpAddr)

net.TCPConn 是允许在客户端和服务器之间的全双工通信的 Go 类型。两种主要方法是

func (c *TCPConn) Write(b []byte) (n int, err os.Error) 
func (c *TCPConn) Read(b []byte) (n int, err os.Error)

TCPConn 被客户端和服务器用来读写消息。

客户端和服务器通过它交换消息。通常情况下,客户端使用 TCPConn 写入请求到服务器,并从 TCPConn 的读取响应。持续如此,直到任一(或两者)的两侧关闭连接。

客户端使用该函数建立一个 TCP 连接使用例:

import (
"fmt"
"io/ioutil"
"net"
"os"
)

func main() {
tcpAddr, err := net.ResolveTCPAddr("tcp", "www.baidu.com:80")
checkError(err)

// 建立网络连接
conn, err := net.DialTCP("tcp", nil, tcpAddr)
// 其实可以直接 conn, err := net.Dial("tcp", service)
checkError(err)

// 调用返回的连接对象提供的 Write 方法发送请求
_, err = conn.Write([]byte("HEAD /HTTP/1.0\r\n\r\n"))
checkError(err)

result, err := ioutil.ReadAll(conn)
checkError(err)

fmt.Println(string(result))
}

func checkError(err error) {
if err != nil {
fmt.Println(err)
os.Exit(1)
}
}

output

HTTP/1.1 400 Bad Request


构建一个 TCP 服务端

上面只是介绍了客户端,这里介绍创建一个服务端:

这里以构建一个时间(Daytime)服务为例:

时间(Daytime)服务。这是一个标准的互联网服务,由 RFC 867 定义,默认的端口 13,协议是 TCP 和 UDP。不过因为安全的原因,几乎没有任何站点运行着时间(Daytime)服务器。

时间(Daytime)服务非常简单,只是将当前时间写入到客户端,关闭该连接,并继续等待下一个客户端。

在一个服务器上注册并监听一个端口。然后它阻塞在一个 "accept" 操作,并等待客户端连接。 当一个客户端连接,accept 调用返回一个连接(connection)对象。

主要使用以下两个函数

func ListenTCP(net string, laddr *TCPAddr) (l *TCPListener, err os.Error)
func (l *TCPListener) Accept() (c Conn, err os.Error)

如果想监听所有网络接口,IP 地址应设置为 0,或如果只是想监听一个简单网络接口,IP 地址可以设置为该网络的地址。

如果端口设置为 0,O/S 会为你选择一个端口。否则,你可以选择你自己的。需要注意的是,在 Unix 系统上,除非你是监控系统,否则不能监听低于 1024 的端口,小于 128 的端口是由 IETF 标准化。

使用例:

func main() {
tcpAddr, err := net.ResolveTCPAddr("tcp", ":1200")
checkError(err)

listener, err := net.ListenTCP("tcp", tcpAddr)
checkError(err)

for {
conn, err := listener.Accept()
if err != nil {
continue
}

daytime := time.Now().String()
conn.Write([]byte(daytime + "\n"))
conn.Close() // 结束连接
}
}

func checkError(err error) {
if err != nil {
fmt.Println(err)
os.Exit(1)
}
}

使用例:

$ telnet localhost 1200
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
2022-02-15 23:42:41.1062418 +0800 CST m=+2.502406201
Connection closed by foreign host.
$

多线程服务器

上面的服务器有一个明显的问题: 它是单线程的。当有一个客户端连接到它,就没有其他的客户端可以连接上。其他客户端将被阻塞,可能会超时。所以可以使用 goroutine 来解决

func main() {
tcpAddr, err := net.ResolveTCPAddr("tcp", ":1201")
checkError(err)

listener, err := net.ListenTCP("tcp", tcpAddr)
checkError(err)

for {
conn, err := listener.Accept()
if err != nil {
continue
}

go handleClient(conn)
}
}

// 处理请求
func handleClient(conn net.Conn) {
defer conn.Close()
var buf [512]byte
for {
// read up to 512 byte
n, err := conn.Read(buf[0:])
if err != nil {
return
}

// write the n bytes read
_, err = conn.Write(buf[0:n])
if err != nil {
return
}
}
}

func checkError(err error) {
if err != nil {
fmt.Println(err)
os.Exit(1)
}
}

TCP 连接控制

超时控制

服务端会断开那些超时的客户端,如果他们响应不够快,比如没有及时往服务端写一个请求。

在套接字读写前,使用这个 SetTimeout 函数可以控制超时时间

func (c *TCPConn) SetTimeout(nsec int64) error

设置 KeepAlive(心跳)

即使没有任何通信,一个客户端可能希望保持连接到服务器的状态。可以给 SetKeepAlive 函数传入 true 来打开 TCP KeepAlive 机制。而 SetKeepAlivePeriod 它同时设置了空闲时间和重试间隔时间,而重试间隔次数则使用系统的默认值。

注意,这个 KeepAlive 也是一个 探活协议

所以如果我设置 5 * time.Second。那么它可能是等待 5 秒钟,发送 ping 并等待另一个5秒。并且 8 次重试(取决于系统设置)。

func (c *TCPConn) SetKeepAlive(keepalive bool) error
func (c *TCPConn) SetKeepAlivePeriod(d time.Duration) error

使用例:

conn.SetKeepAlive(true)
conn.SetKeepAlivePeriod(time.Second * 30)

这个 KeepAlive 机制更多细节可以参考 不为人知的网络编程(十二):彻底搞懂TCP协议层的KeepAlive保活机制

监听多个端口

一个服务器可能不止在一个端口监听客户端,它可能同时监听多个端口,在这种情况下,它在端口之间使用某种轮询机制。

在 C 中, 调用的内核 select() 可以完成这项工作(epoll)。而在 Go 中要同时监听多个端口,只需要通过为每个端口使用一个不同的 goroutine 来完成。

func handler1(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello!")
}

func main() {
ports := []string{":25000", ":25001"}
for _, v := range ports {
go func(port string) { //每个端口都扔进一个 goroutine 中去监听
mux := http.NewServeMux()
mux.HandleFunc("/", handler1)
http.ListenAndServe(port, mux)
}(v)
}
select {}
}
提示

不过实际上 Go 里面还是 C 通过 epoll 去通知这个端口消息的,具体可以参考这篇 从源代码角度看epoll在Go中的使用(一)

References

《Go网络编程》 不为人知的网络编程(十二):彻底搞懂TCP协议层的KeepAlive保活机制 Socket 编程(一):Dial 函数及其使用